package io.kiki.stack.http.feign.codec;

import io.kiki.stack.http.feign.*;

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.Map;

import static io.kiki.stack.http.feign.FeignException.errorStatus;
import static io.kiki.stack.http.feign.Util.RETRY_AFTER;
import static io.kiki.stack.http.feign.Util.checkNotNull;
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
import static java.util.concurrent.TimeUnit.SECONDS;

/**
 * Allows you to massage an exception into a application-specific one. Converting out to a throttle
 * exception are examples of this in use.
 *
 * <p>Ex:
 *
 * <pre>
 * class IllegalArgumentExceptionOn404Decoder implements ErrorDecoder {
 *
 *   &#064;Override
 *   public Exception decode(String methodKey, Response response) {
 *     if (response.status() == 400)
 *       throw new IllegalArgumentException(&quot;bad zone name&quot;);
 *     return new ErrorDecoder.Default().decode(methodKey, response);
 *   }
 *
 * }
 * </pre>
 *
 * <p><b>Error handling</b>
 *
 * <p>Responses where {@link Response#status()} is not in the 2xx range are classified as errors,
 * addressed by the {@link ErrorDecoder}. That said, certain RPC apis return errors defined in the
 * {@link Response#body()} even on a 200 status. For example, in the DynECT api, a job still running
 * condition is returned with a 200 status, encoded in json. When scenarios like this occur, you
 * should raise an application-specific exception (which may be {@link RetryableException
 * retryable}).
 *
 * <p><b>Not Found Semantics</b>
 *
 * <p>It is commonly the case that 404 (Not Found) status has semantic value in HTTP apis. While the
 * default behavior is to raise exeception, users can alternatively enable 404 processing via {@link
 * Feign.Builder#dismiss404()}.
 */
public interface ErrorDecoder {

    /**
     * Implement this method in order to decode an HTTP {@link Response} when {@link
     * Response#status()} is not in the 2xx range. Please raise application-specific exceptions where
     * possible. If your exception is retryable, wrap or subclass {@link RetryableException}
     *
     * @param methodKey {@link Feign#configKey} of the java method that invoked the request. ex.
     *                  {@code IAM#getUser()}
     * @param response  HTTP response where {@link Response#status() status} is greater than or equal
     *                  to {@code 300}.
     * @return Exception IOException, if there was a network error reading the response or an
     * application-specific exception decoded by the implementation. If the throwable is
     * retryable, it should be wrapped, or a subtype of {@link RetryableException}
     */
    Exception decode(String methodKey, Response response);

    class Default implements ErrorDecoder {

        private final RetryAfterDecoder retryAfterDecoder = new RetryAfterDecoder();
        private final Integer maxBodyBytesLength;
        private final Integer maxBodyCharsLength;

        public Default() {
            this.maxBodyBytesLength = null;
            this.maxBodyCharsLength = null;
        }

        public Default(Integer maxBodyBytesLength, Integer maxBodyCharsLength) {
            this.maxBodyBytesLength = maxBodyBytesLength;
            this.maxBodyCharsLength = maxBodyCharsLength;
        }

        @Override
        public Exception decode(String methodKey, Response response) {
            FeignException exception = errorStatus(methodKey, response, maxBodyBytesLength, maxBodyCharsLength);
            Long retryAfter = retryAfterDecoder.apply(firstOrNull(response.headers(), RETRY_AFTER));
            if (retryAfter != null) {
                return new RetryableException(response.status(), exception.getMessage(), response.request().httpMethod(), exception, retryAfter, response.request());
            }
            return exception;
        }

        private <T> T firstOrNull(Map<String, Collection<T>> map, String key) {
            if (map.containsKey(key) && !map.get(key).isEmpty()) {
                return map.get(key).iterator().next();
            }
            return null;
        }
    }

    /**
     * Decodes a {@link Util#RETRY_AFTER} header into an epoch millisecond, if possible.<br>
     * See <a href="https://tools.ietf.org/html/rfc2616#section-14.37">Retry-After format</a>
     */
    class RetryAfterDecoder {

        private final DateTimeFormatter dateTimeFormatter;

        RetryAfterDecoder() {
            this(RFC_1123_DATE_TIME);
        }

        RetryAfterDecoder(DateTimeFormatter dateTimeFormatter) {
            this.dateTimeFormatter = checkNotNull(dateTimeFormatter, "dateTimeFormatter");
        }

        protected long currentTimeMillis() {
            return System.currentTimeMillis();
        }

        /**
         * returns an epoch millisecond that corresponds to the first time a request can be retried.
         *
         * @param retryAfter String in <a href="https://tools.ietf.org/html/rfc2616#section-14.37"
         *                   >Retry-After format</a>
         */
        public Long apply(String retryAfter) {
            if (retryAfter == null) {
                return null;
            }
            if (retryAfter.matches("^[0-9]+\\.?0*$")) {
                retryAfter = retryAfter.replaceAll("\\.0*$", "");
                long deltaMillis = SECONDS.toMillis(Long.parseLong(retryAfter));
                return currentTimeMillis() + deltaMillis;
            }
            try {
                return ZonedDateTime.parse(retryAfter, dateTimeFormatter).toInstant().toEpochMilli();
            } catch (NullPointerException | DateTimeParseException ignored) {
                return null;
            }
        }
    }
}
